feat(spec): gate the liveness README's state table against GOVERNED (#7257) - #7374
Conversation
…7257) The ledger README's "Current state" table is the liveness ledger's own index — one row per governed type, counts regenerated from the gate's --json report, a hand-written Notes cell per row. Its heading, `## Current state — N governed types (complete registry coverage)`, is a completeness CLAIM that nothing could falsify: N was the count of ROWS, not of governed types, and the two agreed only by coincidence. They stopped. `api` and `capability` were both in GOVERNED, both had ledgers, both were counted by the gate, and neither had a row. Same shape the file spends 500 lines warning about, one level up: `dashboard. widgets` asserted in prose that its 22 child keys were classified in a subtree that never existed, and survived a release because prose cannot fail a build (#4956). Every other claim in the file became data the gate resolves; the index was the last one riding on a human reading it. - readme-table.mts — the fourth direction, pure and unit-tested for the same reason as orphans.mts: on a green tree the table is complete, so a passing gate proves nothing about whether the check can fire. Fails (not warns) on a missing row, an orphan row, a duplicated row, or a heading N that disagrees with either the row count or GOVERNED.length. All three heading legs, because two of them agreeing is exactly the state this issue found. - No new check:/gen: script — it rides inside check:liveness, which the Spec property liveness workflow already runs on packages/spec/**. check:generated --reconcile-only stays green with no ledger change. - The documented regeneration snippet now reads the table back as well as the report, so a governed type with no row prints a SKELETON row: the omission is visible at regeneration time as well as at CI time. - The two missing rows are back-filled from their seeding PRs' own measurements — counts from --json, prose from what #5271/PR #5312 and #5961/PR #6540 actually measured. The Notes cell is the one thing the gate deliberately does not check, which is why they were filed rather than fixed on the spot. - Also removes a stray blank line that had been splitting the table in two. Reverse verification is pinned in CI, not just performed once: three cases in check-liveness.test.ts drive the REAL gate against a copy of the real README with a row deleted, the heading skewed, and an ungoverned row added, and read exit 1. Closes #7257
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #7257
Option 3 as the spine, option 2 riding, option 1 by measurement only.
Premise check (on merged
main,f188ed6)PR #7255 landed earlier today: it moved the heading to 30 and added a note admitting the table carried 28 of them, deliberately not back-filling. That premise held exactly — before this change the section read
## Current state — 30 governed types (complete registry coverage)over 28 rows, withGOVERNEDat 30. The new gate's first run against that tree reproduced it without being told:#7255's admission note is replaced (not merely edited) — the paragraph that said "filed rather than back-filled from a guess" now records that the heading is checked and that the rows were back-filled from measurement, keeping its reasoning about why the Notes cell is the part a gate must not author.
(3) The gate — a fourth direction, wired the existing way
scripts/liveness/readme-table.mts: pure, unit-tested, imported bycheck-liveness.mts, exactly the shape oforphans.mts/drill.mts/producer.mts. No newcheck:/gen:script — it rides insidecheck:liveness, whichspec-liveness-check.ymlalready runs on every PR touchingpackages/spec/**.check:generated --reconcile-only(the #4203 meta-gate) stays green with no ledger change:20 check: + 14 gen: scripts, all classified.It fails, not warns — the population is exact, there is no debt to amortise once the rows are back-filled, and this README's own verdict is that a permanently-noisy check is a check nobody reads. Four findings:
GOVERNEDtype with no rowGOVERNEDdoes not backGOVERNED.lengthwhile a real type has noneN≠ row count, orN≠GOVERNED.lengthUnreadable table lines are reported, never skipped — a row the parser cannot see is a row it cannot govern, which is #4956 again. Fenced code blocks are skipped so the regeneration snippet's
print(f"| {t} | …")template is not read as a row, and only the "Current state" section is parsed so the Status-vocabulary and proof tables above it don't inflate the count.(2) The regeneration snippet prints a skeleton
The documented snippet now reads the table back as well as the report, so a governed type with no row prints a marked skeleton instead of silently not being printed next to 29 siblings that are. Both legs run:
Both were needed: the count columns get regenerated far more often than the row set gets audited. The skeleton stops at the count columns on purpose — it prints a marker where the Notes cell goes, never a guess at what belongs there.
(1) The two rows — counts from
--json, Notes from the landing evidenceCounts are the gate's own report, run fresh (the #4488 method). Every clause of both Notes cells is quoted or paraphrased from a source below; nothing is inferred from the counts or the type name.
api— 25 / 0 / 0 / 2. Sources:liveness/api.json's own_note; issue #5206 (the defect); issue #5271 and its PM acceptance comment for PR #5312; the twotransformentries' notes.25 + 2 = 27 — the seeding measurement and today's
--jsonagree exactly. #5206 measured the pre-fix state:apiin neitherDEFAULT_METADATA_TYPE_REGISTRYnorBUILTIN_METADATA_TYPE_SCHEMAS, soresolveOverlaySchema('api', …)→getMetadataTypeSchema('api')returnedundefinedandsaveMetaItemtook its documented "unregistered type → stored unvalidated" branch, whilegetMetaTypes()could not enumerate it. Theplannedverdict is quoted from the ledger, not restated:capability— 12 / 0 / 0 / 0. Sources:liveness/capability.json's_note; issue #5961; PR #6540 (merged 2026-08-08,Fixes #5961).The gate's 12 and the PR's 5 are the same measurement at two granularities — 5 authorable properties plus the 7 ADR-0010 envelope keys the gate auto-classifies
live(they carrynullverdicts in the file, as onpermission/position). The row says so, because the README's own caveat is that counts are at the gate's one-level walk granularity. PR #6540's reverse verification is recorded because it measured something sharper than it predicted:Both types turn out to be the same worked example, and PR #6540 says so in those words ("enforced but undeclared,#5271 关掉那个洞的镜像") — the mirror of this ledger's usual
declared ≠ enforced. Nothing in either cell is prose I supplied about somebody else's measurement.Also fixed: a stray blank line between the
viewandreportrows that had been splitting the table into two rendered tables.Reverse verification — predicted first, and pinned in CI
Predicted before running: on the pre-backfill tree the gate exits 1 naming exactly
apiandcapabilityplus one heading error (30 vs 28 rows), with no heading-vs-GOVERNEDerror (30 === 30), no orphan rows and no malformed lines. That is verbatim what it printed (above) — the two-of-three-legs-agreeing signature the issue describes.Rather than perform it once, three cases in
check-liveness.test.tsdrive the real gate (--ledger-root=at a copy of the real ledgers, whichcpSynccarriesREADME.mdinto) and read its exit code:qarowqa, and asserts the "never from a guess" prescription survives with the check99 governed typesnotatyperowGates
check:livenesspackages/specvitestreadme-table.test.ts+check-liveness.test.tscheck:scripts-typecheckcheck:generated --reconcile-only(#4203)check:empty-state,check:variant-docs,check:strictness-ledgerscripts/check-nul-bytes.mjsc3a3aceHeavy runs under
flock /tmp/os-heavy-verify.lock.Re-checked against
mainat3c03725(3 commits ahead of the branch point): merges clean, andcheck:livenessis green on the merged tree — verified in a throwaway worktree so this branch's green CI stays untouched. One incoming commit editsliveness/action.json, which changes no row of the table's row set and noGOVERNEDentry.Scope notes
packages/spec/scripts/liveness/**,packages/spec/liveness/README.mdand a changeset. Nothing undersrc/.@objectstack/specpatch. Justified rather than skipped —liveness/is in the package's publishedfiles, so the README ships to consumers, and the repo's precedent for a CI-gate-only change is exactly this (.changeset/reconcile-ledger-in-ci.md,@objectstack/spec: patch, a gate with no artifact).content/docs/releases/and nodocs/adr/**edits. No gate demanded one.--jsonreport #7377: the table's count columns disagree with the documented--jsonmethod on 9 of the 30 rows (field59→66,action34→41,hook11→18,page16→23,seed5→12,webhook11→19, plusflowandviewmoving in thedeadcolumn andappgrowing aplannedthe row does not show). That is a different job from holding the row set toGOVERNED: several rows' Notes cells enumerate their dead sets by hand, so regenerating the numbers without re-reading each Note would leave the count column contradicting the prose beside it — worse than the drift, because the prose is the part a reader believes. Deliberately out of scope, with the per-row evidence work and the strictness 台账「数字/散文分家」:计数与表头转生成物走 os-regen,Class 判定与依据保持手写 —— 终结「干净合并两边都错」(单日 7 例) #5107 precedent written up in spec(liveness): 9 rows of the README state table have count columns that disagree with the gate's--jsonreport #7377.